پردازش پیشرفته ویدیو در مرورگر را فعال کنید. یاد بگیرید چگونه با WebCodecs API به دادههای خام VideoFrame دسترسی پیدا کرده و آنها را برای افکتها و تحلیلهای سفارشی دستکاری کنید.
دسترسی به Plane در VideoFrame WebCodecs: نگاهی عمیق به دستکاری دادههای خام ویدیو
برای سالها، پردازش ویدیویی با عملکرد بالا در مرورگر وب مانند یک رویای دور به نظر میرسید. توسعهدهندگان اغلب به محدودیتهای عنصر <video> و Canvas 2D API محدود بودند که با وجود قدرتمند بودن، گلوگاههای عملکردی ایجاد کرده و دسترسی به دادههای خام زیربنایی ویدیو را محدود میکردند. ظهور WebCodecs API این چشمانداز را به طور اساسی تغییر داده و دسترسی سطح پایین به کدکهای رسانهای داخلی مرورگر را فراهم کرده است. یکی از انقلابیترین ویژگیهای آن، قابلیت دسترسی مستقیم و دستکاری دادههای خام فریمهای ویدیویی منفرد از طریق شیء VideoFrame است.
این مقاله یک راهنمای جامع برای توسعهدهندگانی است که به دنبال فراتر رفتن از پخش ساده ویدیو هستند. ما به بررسی پیچیدگیهای دسترسی به plane در VideoFrame خواهیم پرداخت، مفاهیمی مانند فضاهای رنگی و چیدمان حافظه را رمزگشایی خواهیم کرد و مثالهای عملی برای توانمندسازی شما در ساخت نسل بعدی برنامههای ویدیویی درون مرورگر، از فیلترهای همزمان گرفته تا وظایف پیچیده بینایی کامپیوتر، ارائه خواهیم داد.
پیشنیازها
برای بهرهبرداری حداکثری از این راهنما، شما باید درک خوبی از موارد زیر داشته باشید:
- جاوا اسکریپت مدرن: شامل برنامهنویسی ناهمزمان (
async/await، Promises). - مفاهیم پایه ویدیو: آشنایی با اصطلاحاتی مانند فریم، رزولوشن و کدکها مفید است.
- APIهای مرورگر: تجربه کار با APIهایی مانند Canvas 2D یا WebGL مفید خواهد بود اما کاملاً ضروری نیست.
درک فریمهای ویدیو، فضاهای رنگی و Planeها
قبل از اینکه به API بپردازیم، ابتدا باید یک مدل ذهنی محکم از شکل واقعی دادههای یک فریم ویدیو بسازیم. یک ویدیوی دیجیتال دنبالهای از تصاویر ثابت یا فریمها است. هر فریم یک شبکه از پیکسلهاست و هر پیکسل یک رنگ دارد. نحوه ذخیره آن رنگ توسط فضای رنگی و فرمت پیکسل تعریف میشود.
RGBA: زبان مادری وب
بیشتر توسعهدهندگان وب با مدل رنگی RGBA آشنا هستند. هر پیکسل با چهار مؤلفه نمایش داده میشود: قرمز (Red)، سبز (Green)، آبی (Blue) و آلفا (شفافیت). دادهها معمولاً به صورت درهمتنیده (interleaved) در حافظه ذخیره میشوند، به این معنی که مقادیر R، G، B و A برای یک پیکسل واحد به صورت متوالی ذخیره میشوند:
[R1, G1, B1, A1, R2, G2, B2, A2, ...]
در این مدل، کل تصویر در یک بلوک حافظه واحد و پیوسته ذخیره میشود. میتوانیم این را به عنوان داشتن یک "plane" واحد از دادهها در نظر بگیریم.
YUV: زبان فشردهسازی ویدیو
کدکهای ویدیویی، با این حال، به ندرت به طور مستقیم با RGBA کار میکنند. آنها فضاهای رنگی YUV (یا به طور دقیقتر، Y'CbCr) را ترجیح میدهند. این مدل اطلاعات تصویر را به موارد زیر جدا میکند:
- Y (Luma): اطلاعات روشنایی یا مقیاس خاکستری. چشم انسان به تغییرات در luma بسیار حساس است.
- U (Cb) و V (Cr): اطلاعات کروما یا تفاوت رنگ. چشم انسان به جزئیات رنگی کمتر از جزئیات روشنایی حساس است.
این جداسازی کلید فشردهسازی کارآمد است. با کاهش رزولوشن مؤلفههای U و V - تکنیکی به نام نمونهبرداری کروما (chroma subsampling) - میتوانیم اندازه فایل را به طور قابل توجهی با حداقل افت کیفیت محسوس کاهش دهیم. این منجر به فرمتهای پیکسلی صفحهای (planar) میشود، جایی که مؤلفههای Y، U و V در بلوکهای حافظه جداگانه یا "plane"ها ذخیره میشوند.
یک فرمت رایج I420 (نوعی از YUV 4:2:0) است که در آن برای هر بلوک 2x2 از پیکسلها، چهار نمونه Y وجود دارد اما فقط یک نمونه U و یک نمونه V وجود دارد. این بدان معناست که planeهای U و V نصف عرض و نصف ارتفاع plane مربوط به Y را دارند.
درک این تمایز حیاتی است زیرا WebCodecs به شما دسترسی مستقیم به همین planeها را میدهد، دقیقاً همانطور که دیکودر آنها را ارائه میدهد.
شیء VideoFrame: دروازه شما به دادههای پیکسل
بخش مرکزی این پازل، شیء VideoFrame است. این شیء نمایانگر یک فریم واحد از ویدیو است و نه تنها دادههای پیکسل، بلکه فرادادههای مهمی را نیز در بر میگیرد.
ویژگیهای کلیدی VideoFrame
format: رشتهای که فرمت پیکسل را نشان میدهد (مثلاً 'I420', 'NV12', 'RGBA').codedWidth/codedHeight: ابعاد کامل فریم همانطور که در حافظه ذخیره شده است، شامل هرگونه پدینگ (padding) مورد نیاز کدک.displayWidth/displayHeight: ابعادی که باید برای نمایش فریم استفاده شود.timestamp: مهر زمانی ارائه فریم به میکروثانیه.duration: مدت زمان فریم به میکروثانیه.
متد جادویی: copyTo()
متد اصلی برای دسترسی به دادههای خام پیکسل videoFrame.copyTo(destination, options) است. این متد ناهمزمان، دادههای plane فریم را در بافری که شما ارائه میدهید کپی میکند.
destination: یکArrayBufferیا یک آرایه تایپشده (مانندUint8Array) که به اندازه کافی بزرگ باشد تا دادهها را در خود جای دهد.options: شیئی که مشخص میکند کدام planeها کپی شوند و چیدمان حافظه آنها چگونه باشد. اگر این گزینه حذف شود، تمام planeها در یک بافر پیوسته واحد کپی میشوند.
این متد یک Promise را برمیگرداند که با آرایهای از اشیاء PlaneLayout، یکی برای هر plane در فریم، resolve میشود. هر شیء PlaneLayout شامل دو قطعه اطلاعات حیاتی است:
offset: آفست بایتی که دادههای این plane در بافر مقصد از آنجا شروع میشود.stride: تعداد بایتها بین شروع یک ردیف از پیکسلها و شروع ردیف بعدی برای آن plane.
یک مفهوم حیاتی: Stride در مقابل Width
این یکی از رایجترین منابع سردرگمی برای توسعهدهندگانی است که تازه با برنامهنویسی گرافیکی سطح پایین آشنا شدهاند. شما نمیتوانید فرض کنید که هر ردیف از دادههای پیکسل به طور فشرده پشت سر هم قرار گرفتهاند.
- Width تعداد پیکسلها در یک ردیف از تصویر است.
- Stride (که pitch یا line step نیز نامیده میشود) تعداد بایتها در حافظه از ابتدای یک ردیف تا ابتدای ردیف بعدی است.
اغلب، stride بزرگتر از width * bytes_per_pixel خواهد بود. این به این دلیل است که حافظه اغلب برای همترازی با مرزهای سختافزاری (مثلاً مرزهای 32 یا 64 بایتی) برای پردازش سریعتر توسط CPU یا GPU پد (pad) میشود. شما همیشه باید از stride برای محاسبه آدرس حافظه یک پیکسل در یک ردیف خاص استفاده کنید.
نادیده گرفتن stride منجر به تصاویر کج یا تحریف شده و دسترسی نادرست به دادهها خواهد شد.
مثال عملی ۱: دسترسی و نمایش یک Plane خاکستری
بیایید با یک مثال ساده اما قدرتمند شروع کنیم. بیشتر ویدیوها در وب با فرمت YUV مانند I420 کدگذاری میشوند. plane مربوط به 'Y' عملاً یک نمایش کامل خاکستری از تصویر است. ما میتوانیم فقط این plane را استخراج کرده و آن را روی یک canvas رندر کنیم.
async function displayGrayscale(videoFrame) {
// فرض میکنیم videoFrame در فرمت YUV مانند 'I420' یا 'NV12' است.
if (!videoFrame.format.startsWith('I4')) {
console.error('این مثال به یک فرمت planar از نوع YUV 4:2:0 نیاز دارد.');
videoFrame.close();
return;
}
const yPlaneInfo = videoFrame.layout[0]; // plane مربوط به Y همیشه اولین است.
// یک بافر برای نگهداری دادههای plane مربوط به Y ایجاد میکنیم.
const yPlaneData = new Uint8Array(yPlaneInfo.stride * videoFrame.codedHeight);
// plane مربوط به Y را در بافر خود کپی میکنیم.
await videoFrame.copyTo(yPlaneData, {
rect: { x: 0, y: 0, width: videoFrame.codedWidth, height: videoFrame.codedHeight },
layout: [yPlaneInfo]
});
// اکنون، yPlaneData حاوی پیکسلهای خام خاکستری است.
// باید آن را رندر کنیم. یک بافر RGBA برای canvas ایجاد خواهیم کرد.
const canvas = document.getElementById('my-canvas');
canvas.width = videoFrame.displayWidth;
canvas.height = videoFrame.displayHeight;
const ctx = canvas.getContext('2d');
const imageData = ctx.createImageData(canvas.width, canvas.height);
// روی پیکسلهای canvas پیمایش کرده و آنها را از دادههای plane مربوط به Y پر میکنیم.
for (let y = 0; y < videoFrame.displayHeight; y++) {
for (let x = 0; x < videoFrame.displayWidth; x++) {
// مهم: از stride برای پیدا کردن اندیس صحیح مبدأ استفاده کنید!
const yIndex = y * yPlaneInfo.stride + x;
const luma = yPlaneData[yIndex];
// اندیس مقصد را در بافر ImageData از نوع RGBA محاسبه میکنیم.
const rgbaIndex = (y * canvas.width + x) * 4;
imageData.data[rgbaIndex] = luma; // قرمز
imageData.data[rgbaIndex + 1] = luma; // سبز
imageData.data[rgbaIndex + 2] = luma; // آبی
imageData.data[rgbaIndex + 3] = 255; // آلفا
}
}
ctx.putImageData(imageData, 0, 0);
// حیاتی: همیشه VideoFrame را برای آزاد کردن حافظهاش ببندید.
videoFrame.close();
}
این مثال چندین مرحله کلیدی را برجسته میکند: شناسایی چیدمان صحیح plane، تخصیص بافر مقصد، استفاده از copyTo برای استخراج دادهها، و پیمایش صحیح دادهها با استفاده از stride برای ساخت یک تصویر جدید.
مثال عملی ۲: دستکاری درجا (فیلتر سپیا)
حالا بیایید یک دستکاری مستقیم داده را انجام دهیم. فیلتر سپیا یک افکت کلاسیک است که پیادهسازی آن آسان است. برای این مثال، کار با یک فریم RGBA، که ممکن است از یک canvas یا یک زمینه WebGL دریافت کنید، آسانتر است.
async function applySepiaFilter(videoFrame) {
// این مثال فرض میکند فریم ورودی 'RGBA' یا 'BGRA' است.
if (videoFrame.format !== 'RGBA' && videoFrame.format !== 'BGRA') {
console.error('مثال فیلتر سپیا به یک فریم RGBA نیاز دارد.');
videoFrame.close();
return null;
}
// یک بافر برای نگهداری دادههای پیکسل تخصیص میدهیم.
const frameDataSize = videoFrame.allocationSize();
const frameData = new Uint8Array(frameDataSize);
await videoFrame.copyTo(frameData);
const layout = videoFrame.layout[0]; // RGBA یک plane واحد است
// اکنون، دادهها را در بافر دستکاری میکنیم.
for (let y = 0; y < videoFrame.codedHeight; y++) {
for (let x = 0; x < videoFrame.codedWidth; x++) {
const pixelIndex = y * layout.stride + x * 4; // ۴ بایت برای هر پیکسل (R,G,B,A)
const r = frameData[pixelIndex];
const g = frameData[pixelIndex + 1];
const b = frameData[pixelIndex + 2];
const tr = 0.393 * r + 0.769 * g + 0.189 * b;
const tg = 0.349 * r + 0.686 * g + 0.168 * b;
const tb = 0.272 * r + 0.534 * g + 0.131 * b;
frameData[pixelIndex] = Math.min(255, tr);
frameData[pixelIndex + 1] = Math.min(255, tg);
frameData[pixelIndex + 2] = Math.min(255, tb);
// آلفا (frameData[pixelIndex + 3]) بدون تغییر باقی میماند.
}
}
// یک VideoFrame *جدید* با دادههای اصلاحشده ایجاد میکنیم.
const newFrame = new VideoFrame(frameData, {
format: videoFrame.format,
codedWidth: videoFrame.codedWidth,
codedHeight: videoFrame.codedHeight,
timestamp: videoFrame.timestamp,
duration: videoFrame.duration
});
// فراموش نکنید فریم اصلی را ببندید!
videoFrame.close();
return newFrame;
}
این یک چرخه کامل خواندن-تغییر-نوشتن را نشان میدهد: کپی کردن دادهها، پیمایش آنها با استفاده از stride، اعمال یک تبدیل ریاضی به هر پیکسل، و ساخت یک VideoFrame جدید با دادههای حاصل. این فریم جدید سپس میتواند روی یک canvas رندر شود، به یک VideoEncoder ارسال شود، یا به مرحله پردازش دیگری منتقل شود.
عملکرد مهم است: جاوا اسکریپت در مقابل وباسمبلی (WASM)
پیمایش میلیونها پیکسل برای هر فریم (یک فریم 1080p بیش از ۲ میلیون پیکسل یا ۸ میلیون نقطه داده در RGBA دارد) در جاوا اسکریپت میتواند کند باشد. در حالی که موتورهای JS مدرن فوقالعاده سریع هستند، برای پردازش همزمان ویدیوی با وضوح بالا (HD, 4K)، این رویکرد میتواند به راحتی نخ اصلی (main thread) را تحت فشار قرار دهد و منجر به تجربه کاربری ناپایدار شود.
اینجاست که وباسمبلی (WASM) به یک ابزار ضروری تبدیل میشود. WASM به شما اجازه میدهد کدی را که به زبانهایی مانند C++، Rust یا Go نوشته شده است، با سرعتی نزدیک به سرعت بومی (native) در داخل مرورگر اجرا کنید. گردش کار برای پردازش ویدیو به این صورت میشود:
- در جاوا اسکریپت: از
videoFrame.copyTo()برای دریافت دادههای خام پیکسل در یکArrayBufferاستفاده کنید. - ارسال به WASM: یک ارجاع به این بافر را به ماژول WASM کامپایل شده خود ارسال کنید. این یک عملیات بسیار سریع است زیرا شامل کپی کردن دادهها نمیشود.
- در WASM (C++/Rust): الگوریتمهای پردازش تصویر بسیار بهینه شده خود را مستقیماً روی بافر حافظه اجرا کنید. این کار به مراتب سریعتر از یک حلقه جاوا اسکریپت است.
- بازگشت به جاوا اسکریپت: پس از اتمام کار WASM، کنترل به جاوا اسکریپت باز میگردد. سپس میتوانید از بافر اصلاح شده برای ایجاد یک
VideoFrameجدید استفاده کنید.
برای هر برنامه جدی دستکاری ویدیوی همزمان - مانند پسزمینههای مجازی، تشخیص اشیاء یا فیلترهای پیچیده - استفاده از وباسمبلی فقط یک گزینه نیست؛ یک ضرورت است.
مدیریت فرمتهای مختلف پیکسل (مانند I420, NV12)
در حالی که RGBA ساده است، شما اغلب فریمها را در فرمتهای YUV صفحهای از یک VideoDecoder دریافت خواهید کرد. بیایید ببینیم چگونه یک فرمت کاملاً صفحهای مانند I420 را مدیریت کنیم.
یک VideoFrame با فرمت I420 سه توصیفگر چیدمان در آرایه layout خود خواهد داشت:
layout[0]: plane مربوط به Y (luma). ابعادcodedWidthxcodedHeightاست.layout[1]: plane مربوط به U (chroma). ابعادcodedWidth/2xcodedHeight/2است.layout[2]: plane مربوط به V (chroma). ابعادcodedWidth/2xcodedHeight/2است.
در اینجا نحوه کپی کردن هر سه plane در یک بافر واحد آمده است:
async function extractI420Planes(videoFrame) {
const totalSize = videoFrame.allocationSize({ format: 'I420' });
const allPlanesData = new Uint8Array(totalSize);
const layouts = await videoFrame.copyTo(allPlanesData);
// layouts یک آرایه از ۳ شیء PlaneLayout است
console.log('چیدمان Plane Y:', layouts[0]); // { offset: 0, stride: ... }
console.log('چیدمان Plane U:', layouts[1]); // { offset: ..., stride: ... }
console.log('چیدمان Plane V:', layouts[2]); // { offset: ..., stride: ... }
// اکنون میتوانید به هر plane در بافر `allPlanesData` دسترسی داشته باشید
// با استفاده از offset و stride مشخص آن.
const yPlaneView = new Uint8Array(
allPlanesData.buffer,
layouts[0].offset,
layouts[0].stride * videoFrame.codedHeight
);
// توجه داشته باشید که ابعاد کروما نصف شدهاند!
const uPlaneView = new Uint8Array(
allPlanesData.buffer,
layouts[1].offset,
layouts[1].stride * (videoFrame.codedHeight / 2)
);
const vPlaneView = new Uint8Array(
allPlanesData.buffer,
layouts[2].offset,
layouts[2].stride * (videoFrame.codedHeight / 2)
);
console.log('اندازه plane Y قابل دسترس:', yPlaneView.byteLength);
console.log('اندازه plane U قابل دسترس:', uPlaneView.byteLength);
videoFrame.close();
}
فرمت رایج دیگر NV12 است که نیمهصفحهای (semi-planar) است. این فرمت دو plane دارد: یکی برای Y، و یک plane دوم که در آن مقادیر U و V به صورت درهمتنیده هستند (مثلاً [U1, V1, U2, V2, ...]). WebCodecs API این موضوع را به طور شفاف مدیریت میکند؛ یک VideoFrame با فرمت NV12 به سادگی دو چیدمان در آرایه layout خود خواهد داشت.
چالشها و بهترین شیوهها
کار در این سطح پایین قدرتمند است، اما با مسئولیتهایی همراه است.
مدیریت حافظه از اهمیت بالایی برخوردار است
یک VideoFrame مقدار قابل توجهی از حافظه را نگه میدارد که اغلب خارج از هیپ (heap) جمعآورنده زباله (garbage collector) جاوا اسکریپت مدیریت میشود. اگر این حافظه را به صراحت آزاد نکنید، باعث نشت حافظه میشوید که میتواند تب مرورگر را از کار بیندازد.
همیشه، همیشه پس از اتمام کار با یک فریم، videoFrame.close() را فراخوانی کنید.
طبیعت ناهمزمان
تمام دسترسیها به دادهها ناهمزمان است. معماری برنامه شما باید جریان Promiseها و async/await را به درستی مدیریت کند تا از شرایط رقابتی (race conditions) جلوگیری کرده و یک خط لوله پردازش روان را تضمین کند.
سازگاری با مرورگرها
WebCodecs یک API مدرن است. در حالی که در تمام مرورگرهای اصلی پشتیبانی میشود، همیشه در دسترس بودن آن را بررسی کنید و از هرگونه جزئیات پیادهسازی یا محدودیتهای خاص هر مرورگر آگاه باشید. قبل از تلاش برای استفاده از API، از تشخیص ویژگی (feature detection) استفاده کنید.
نتیجهگیری: مرز جدیدی برای ویدیوی وب
قابلیت دسترسی مستقیم و دستکاری دادههای خام plane یک VideoFrame از طریق WebCodecs API یک تغییر پارادایم برای برنامههای رسانهای مبتنی بر وب است. این API جعبه سیاه عنصر <video> را حذف میکند و به توسعهدهندگان کنترل دقیقی را میدهد که قبلاً فقط برای برنامههای بومی (native) محفوظ بود.
با درک اصول چیدمان حافظه ویدیو - planeها، stride و فرمتهای رنگی - و با بهرهگیری از قدرت وباسمبلی برای عملیاتهای حیاتی از نظر عملکرد، اکنون میتوانید ابزارهای پردازش ویدیویی فوقالعاده پیچیدهای را مستقیماً در مرورگر بسازید. از درجهبندی رنگ همزمان و جلوههای بصری سفارشی گرفته تا یادگیری ماشین سمت کلاینت و تحلیل ویدیو، امکانات گستردهای وجود دارد. دوران ویدیوی سطح پایین و با عملکرد بالا در وب به راستی آغاز شده است.